/*
* Copyright 2015 Open mHealth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openmhealth.shim.ihealth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Joiner;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import org.openmhealth.shim.*;
import org.openmhealth.shim.ihealth.mapper.*;
import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.security.oauth2.client.OAuth2RestOperations;
import org.springframework.security.oauth2.client.resource.OAuth2ProtectedResourceDetails;
import org.springframework.security.oauth2.client.resource.UserRedirectRequiredException;
import org.springframework.security.oauth2.client.token.AccessTokenRequest;
import org.springframework.security.oauth2.client.token.RequestEnhancer;
import org.springframework.security.oauth2.client.token.grant.code.AuthorizationCodeAccessTokenProvider;
import org.springframework.security.oauth2.client.token.grant.code.AuthorizationCodeResourceDetails;
import org.springframework.security.oauth2.common.AuthenticationScheme;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.DefaultOAuth2RefreshToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.common.util.SerializationUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResponseExtractor;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
import java.net.URI;
import java.time.OffsetDateTime;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Map;
import static com.google.common.collect.Lists.newArrayList;
import static java.util.Collections.singletonList;
import static org.openmhealth.shim.ihealth.IHealthShim.IHealthDataTypes.*;
import static org.slf4j.LoggerFactory.getLogger;
/**
* Encapsulates parameters specific to the iHealth REST API and processes requests made of shimmer for iHealth data.
*
* @author Chris Schaefbauer
* @author Emerson Farrugia
*/
@Component
@EnableConfigurationProperties
@ConfigurationProperties(prefix = "openmhealth.shim.ihealth")
public class IHealthShim extends OAuth2ShimBase {
public static final String SHIM_KEY = "ihealth";
private static final String API_URL = "https://api.ihealthlabs.com:8443/openapiv2/";
private static final String AUTHORIZE_URL = "https://api.ihealthlabs.com:8443/OpenApiV2/OAuthv2/userauthorization/";
private static final String TOKEN_URL = AUTHORIZE_URL;
public static final List<String> IHEALTH_SCOPES = Arrays.asList("OpenApiActivity", "OpenApiBP", "OpenApiSleep",
"OpenApiWeight", "OpenApiBG", "OpenApiSpO2", "OpenApiUserInfo", "OpenApiFood", "OpenApiSport");
private static final Logger logger = getLogger(IHealthShim.class);
@Autowired
public IHealthShim(ApplicationAccessParametersRepo applicationParametersRepo,
AuthorizationRequestParametersRepo authorizationRequestParametersRepo,
AccessParametersRepo accessParametersRepo,
ShimServerConfig shimServerConfig) {
super(applicationParametersRepo, authorizationRequestParametersRepo, accessParametersRepo, shimServerConfig);
}
@Override
public String getLabel() {
return "iHealth";
}
@Override
public String getShimKey() {
return SHIM_KEY;
}
@Override
public String getBaseAuthorizeUrl() {
return AUTHORIZE_URL;
}
@Override
public String getBaseTokenUrl() {
return TOKEN_URL;
}
@Override
public List<String> getScopes() {
return IHEALTH_SCOPES;
}
@Override
public AuthorizationCodeAccessTokenProvider getAuthorizationCodeAccessTokenProvider() {
return new IHealthAuthorizationCodeAccessTokenProvider();
}
@Override
public ShimDataType[] getShimDataTypes() {
return new ShimDataType[] {
PHYSICAL_ACTIVITY,
BLOOD_GLUCOSE,
BLOOD_PRESSURE,
BODY_WEIGHT,
BODY_MASS_INDEX,
HEART_RATE,
STEP_COUNT,
SLEEP_DURATION,
OXYGEN_SATURATION
};
}
/**
* Map of values auto-configured from the application.yaml.
*/
Map<String, String> serialValues;
public Map<String, String> getSerialValues() {
return serialValues;
}
public void setSerialValues(Map<String, String> serialValues) {
this.serialValues = serialValues;
}
public enum IHealthDataTypes implements ShimDataType {
PHYSICAL_ACTIVITY(singletonList("sport.json")),
BLOOD_GLUCOSE(singletonList("glucose.json")),
BLOOD_PRESSURE(singletonList("bp.json")),
BODY_WEIGHT(singletonList("weight.json")),
BODY_MASS_INDEX(singletonList("weight.json")),
HEART_RATE(newArrayList("bp.json", "spo2.json")),
STEP_COUNT(singletonList("activity.json")),
SLEEP_DURATION(singletonList("sleep.json")),
OXYGEN_SATURATION(singletonList("spo2.json"));
private List<String> endPoint;
IHealthDataTypes(List<String> endPoint) {
this.endPoint = endPoint;
}
public List<String> getEndPoint() {
return endPoint;
}
}
@Override
protected ResponseEntity<ShimDataResponse> getData(OAuth2RestOperations restTemplate,
ShimDataRequest shimDataRequest) throws ShimException {
final IHealthDataTypes dataType;
try {
dataType = valueOf(
shimDataRequest.getDataTypeKey().trim().toUpperCase());
}
catch (NullPointerException | IllegalArgumentException e) {
throw new ShimException("Null or Invalid data type parameter: "
+ shimDataRequest.getDataTypeKey()
+ " in shimDataRequest, cannot retrieve data.");
}
OffsetDateTime now = OffsetDateTime.now();
OffsetDateTime startDate = shimDataRequest.getStartDateTime() == null ?
now.minusDays(1) : shimDataRequest.getStartDateTime();
OffsetDateTime endDate = shimDataRequest.getEndDateTime() == null ?
now.plusDays(1) : shimDataRequest.getEndDateTime();
/*
The physical activity point handles start and end datetimes differently than the other endpoints. It
requires use to include the range until the beginning of the next day.
*/
if (dataType == PHYSICAL_ACTIVITY) {
endDate = endDate.plusDays(1);
}
// SC and SV values are client-based keys that are unique to each endpoint within a project
String scValue = getScValue();
List<String> svValues = getSvValues(dataType);
List<JsonNode> responseEntities = newArrayList();
int i = 0;
// We iterate because one of the measures (Heart rate) comes from multiple endpoints, so we submit
// requests to each of these endpoints, map the responses separately and then combine them
for (String endPoint : dataType.getEndPoint()) {
UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(API_URL);
// Need to use a dummy userId if we haven't authenticated yet. This is the case where we are using
// getData to trigger Spring to conduct the OAuth exchange
String userId = "uk";
if (shimDataRequest.getAccessParameters() != null) {
OAuth2AccessToken token =
SerializationUtils.deserialize(shimDataRequest.getAccessParameters().getSerializedToken());
userId = Preconditions.checkNotNull((String) token.getAdditionalInformation().get("UserID"));
uriBuilder.queryParam("access_token", token.getValue());
}
uriBuilder.path("/user/")
.path(userId + "/")
.path(endPoint)
.queryParam("client_id", restTemplate.getResource().getClientId())
.queryParam("client_secret", restTemplate.getResource().getClientSecret())
.queryParam("start_time", startDate.toEpochSecond())
.queryParam("end_time", endDate.toEpochSecond())
.queryParam("locale", "default")
.queryParam("sc", scValue)
.queryParam("sv", svValues.get(i));
ResponseEntity<JsonNode> responseEntity;
try {
URI url = uriBuilder.build().encode().toUri();
responseEntity = restTemplate.getForEntity(url, JsonNode.class);
}
catch (HttpClientErrorException | HttpServerErrorException e) {
// FIXME figure out how to handle this
logger.error("A request for iHealth data failed.", e);
throw e;
}
if (shimDataRequest.getNormalize()) {
IHealthDataPointMapper mapper;
switch ( dataType ) {
case PHYSICAL_ACTIVITY:
mapper = new IHealthPhysicalActivityDataPointMapper();
break;
case BLOOD_GLUCOSE:
mapper = new IHealthBloodGlucoseDataPointMapper();
break;
case BLOOD_PRESSURE:
mapper = new IHealthBloodPressureDataPointMapper();
break;
case BODY_WEIGHT:
mapper = new IHealthBodyWeightDataPointMapper();
break;
case BODY_MASS_INDEX:
mapper = new IHealthBodyMassIndexDataPointMapper();
break;
case STEP_COUNT:
mapper = new IHealthStepCountDataPointMapper();
break;
case SLEEP_DURATION:
mapper = new IHealthSleepDurationDataPointMapper();
break;
case HEART_RATE:
// there are two different mappers for heart rate because the data can come from two endpoints
if (endPoint == "bp.json") {
mapper = new IHealthBloodPressureEndpointHeartRateDataPointMapper();
break;
}
else if (endPoint == "spo2.json") {
mapper = new IHealthBloodOxygenEndpointHeartRateDataPointMapper();
break;
}
case OXYGEN_SATURATION:
mapper = new IHealthOxygenSaturationDataPointMapper();
break;
default:
throw new UnsupportedOperationException();
}
responseEntities.addAll(mapper.asDataPoints(singletonList(responseEntity.getBody())));
}
else {
responseEntities.add(responseEntity.getBody());
}
i++;
}
return ResponseEntity.ok().body(
ShimDataResponse.result(SHIM_KEY, responseEntities));
}
private String getScValue() {
return serialValues.get("SC");
}
private List<String> getSvValues(IHealthDataTypes dataType) {
switch ( dataType ) {
case PHYSICAL_ACTIVITY:
return singletonList(serialValues.get("sportSV"));
case BODY_WEIGHT:
return singletonList(serialValues.get("weightSV"));
case BODY_MASS_INDEX:
return singletonList(serialValues.get("weightSV")); // body mass index comes from the weight endpoint
case BLOOD_PRESSURE:
return singletonList(serialValues.get("bloodPressureSV"));
case BLOOD_GLUCOSE:
return singletonList(serialValues.get("bloodGlucoseSV"));
case STEP_COUNT:
return singletonList(serialValues.get("activitySV"));
case SLEEP_DURATION:
return singletonList(serialValues.get("sleepSV"));
case HEART_RATE:
return newArrayList(serialValues.get("bloodPressureSV"), serialValues.get("spo2SV"));
case OXYGEN_SATURATION:
return singletonList(serialValues.get("spo2SV"));
default:
throw new UnsupportedOperationException();
}
}
@Override
public OAuth2ProtectedResourceDetails getResource() {
AuthorizationCodeResourceDetails resource = (AuthorizationCodeResourceDetails) super.getResource();
resource.setAuthenticationScheme(AuthenticationScheme.none);
return resource;
}
@Override
protected String getAuthorizationUrl(UserRedirectRequiredException exception) {
final OAuth2ProtectedResourceDetails resource = getResource();
UriComponentsBuilder callBackUriBuilder = UriComponentsBuilder.fromUriString(getCallbackUrl())
.queryParam("state", exception.getStateKey());
UriComponentsBuilder authorizationUriBuilder = UriComponentsBuilder.fromUriString(exception.getRedirectUri())
.queryParam("client_id", resource.getClientId())
.queryParam("response_type", "code")
.queryParam("APIName", Joiner.on(' ').join(resource.getScope()))
.queryParam("redirect_uri", callBackUriBuilder.build().toString());
return authorizationUriBuilder.build().encode().toString();
}
public class IHealthAuthorizationCodeAccessTokenProvider extends AuthorizationCodeAccessTokenProvider {
public IHealthAuthorizationCodeAccessTokenProvider() {
this.setTokenRequestEnhancer(new RequestEnhancer() {
@Override
public void enhance(AccessTokenRequest request,
OAuth2ProtectedResourceDetails resource,
MultiValueMap<String, String> form, HttpHeaders headers) {
form.set("client_id", resource.getClientId());
form.set("client_secret", resource.getClientSecret());
form.set("redirect_uri", getCallbackUrl());
form.set("state", request.getStateKey());
}
});
}
@Override
protected HttpMethod getHttpMethod() {
return HttpMethod.GET;
}
@Override
protected ResponseExtractor<OAuth2AccessToken> getResponseExtractor() {
return new ResponseExtractor<OAuth2AccessToken>() {
@Override
public OAuth2AccessToken extractData(ClientHttpResponse response) throws IOException {
JsonNode node = new ObjectMapper().readTree(response.getBody());
String token = Preconditions
.checkNotNull(node.path("AccessToken").textValue(), "Missing access token: %s", node);
String refreshToken = Preconditions
.checkNotNull(node.path("RefreshToken").textValue(), "Missing refresh token: %s" + node);
String userId =
Preconditions.checkNotNull(node.path("UserID").textValue(), "Missing UserID: %s", node);
long expiresIn = node.path("Expires").longValue() * 1000;
Preconditions.checkArgument(expiresIn > 0, "Missing Expires: %s", node);
DefaultOAuth2AccessToken accessToken = new DefaultOAuth2AccessToken(token);
accessToken.setExpiration(new Date(System.currentTimeMillis() + expiresIn));
accessToken.setRefreshToken(new DefaultOAuth2RefreshToken(refreshToken));
accessToken.setAdditionalInformation(ImmutableMap.<String, Object>of("UserID", userId));
return accessToken;
}
};
}
}
}